--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 035fc3f741661d569351189b49f5761e29caa8b1
Parents : a5fef35
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-15T20:26:07-05:00
feat(telephone): improve path discovery and initiation process in TelephoneManager; introduce polling intervals and cancellation checks for improved reliability
Changes
5 files changed, 444 insertions(+), 62 deletions(-)
Diff
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 79c3e37c..43b01924 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -81,6 +81,9 @@ class TelephoneManager:
self.initiation_status = None
self.initiation_target_hash = None
self.on_initiation_status_callback = None
+ self._path_poll_interval_s = 0.05
+ self._path_retry_interval_s = 1.5
+ self._status_poll_interval_s = 0.1
@property
def is_recording(self):
@@ -212,6 +215,30 @@ class TelephoneManager:
RNS.LOG_ERROR,
)
+ def _is_initiation_cancelled(self):
+ return not bool(self.initiation_status)
+
+ async def _await_path(self, destination_hash: bytes, timeout_seconds: float):
+ timeout_after = time.monotonic() + max(0.0, timeout_seconds)
+ next_request_at = 0.0
+
+ while time.monotonic() < timeout_after:
+ if self._is_initiation_cancelled():
+ return False
+
+ if RNS.Transport.has_path(destination_hash):
+ return True
+
+ now = time.monotonic()
+ if now >= next_request_at:
+ with contextlib.suppress(Exception):
+ RNS.Transport.request_path(destination_hash)
+ next_request_at = now + self._path_retry_interval_s
+
+ await asyncio.sleep(self._path_poll_interval_s)
+
+ return RNS.Transport.has_path(destination_hash)
+
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15):
if self.telephone is None:
msg = "Telephone is not initialized"
@@ -282,17 +309,24 @@ class TelephoneManager:
if destination_identity is None:
self._update_initiation_status("Discovering path/identity...")
- RNS.Transport.request_path(destination_hash)
+ timeout_after = time.monotonic() + timeout_seconds
+ next_request_at = 0.0
- # Wait for identity to appear
- start_wait = time.time()
- while time.time() - start_wait < timeout_seconds:
- if not self.initiation_status: # Externally cancelled (hangup)
+ # Wait for identity to appear while also nudging path discovery.
+ while time.monotonic() < timeout_after:
+ if self._is_initiation_cancelled():
return None
- await asyncio.sleep(0.5)
+
+ now = time.monotonic()
+ if now >= next_request_at:
+ with contextlib.suppress(Exception):
+ RNS.Transport.request_path(destination_hash)
+ next_request_at = now + self._path_retry_interval_s
+
destination_identity = resolve_identity(destination_hash_hex)
if destination_identity:
break
+ await asyncio.sleep(self._path_poll_interval_s)
if destination_identity is None:
self._update_initiation_status(None, None)
@@ -301,16 +335,15 @@ class TelephoneManager:
if not RNS.Transport.has_path(destination_hash):
self._update_initiation_status("Requesting path...")
- RNS.Transport.request_path(destination_hash)
-
- # Wait up to 10s for path discovery
- path_wait_start = time.time()
- while time.time() - path_wait_start < min(timeout_seconds, 10):
- if not self.initiation_status: # Externally cancelled
- return None
- if RNS.Transport.has_path(destination_hash):
- break
- await asyncio.sleep(0.5)
+ has_path = await self._await_path(
+ destination_hash,
+ timeout_seconds=min(timeout_seconds, 10),
+ )
+ if self._is_initiation_cancelled():
+ return None
+ if not has_path:
+ msg = "Path not found to destination"
+ raise RuntimeError(msg)
self._update_initiation_status("Establishing link...", destination_hash_hex)
self.call_start_time = time.time()
@@ -323,10 +356,12 @@ class TelephoneManager:
)
start_wait = time.time()
+ cancel_requested = False
# LXST telephone.call usually returns on establishment or timeout.
# We wait for it, but if status becomes established or ended, we can stop waiting.
while not call_task.done():
- if not self.initiation_status: # Externally cancelled
+ if self._is_initiation_cancelled():
+ cancel_requested = True
break
# Update UI status based on current call state
@@ -350,7 +385,12 @@ class TelephoneManager:
time.time() - start_wait > 1.0
): # Available (ended/timeout)
break
- await asyncio.sleep(0.5)
+ await asyncio.sleep(self._status_poll_interval_s)
+
+ if cancel_requested:
+ with contextlib.suppress(Exception):
+ self.telephone.hangup()
+ return None
# If the task finished but we're still ringing or connecting,
# wait a bit more for establishment or definitive failure
@@ -387,7 +427,7 @@ class TelephoneManager:
3,
]: # Established, Busy, Rejected, Ended
break
- await asyncio.sleep(0.5)
+ await asyncio.sleep(self._status_poll_interval_s)
return self.telephone.active_call
@@ -396,15 +436,19 @@ class TelephoneManager:
await asyncio.sleep(3)
raise
finally:
+ if self._is_initiation_cancelled():
+ self._update_initiation_status(None, None)
+ return
+
# Wait for either establishment, failure, or a timeout
# to ensure the UI has something to show (either active_call or initiation_status)
- for _ in range(20): # Max 10 seconds of defensive waiting
+ for _ in range(40): # Max 4 seconds of defensive waiting
if self.telephone and (
self.telephone.active_call
or self.telephone.call_status in [0, 1, 3, 6]
):
break
- await asyncio.sleep(0.5)
+ await asyncio.sleep(self._status_poll_interval_s)
# If call was successful, keep status for a moment to prevent UI flicker
# while the frontend picks up the new active_call state
@@ -412,7 +456,7 @@ class TelephoneManager:
(self.telephone.active_call and self.telephone.call_status == 6)
or self.telephone.call_status in [2, 4, 5]
):
- await asyncio.sleep(2.0)
+ await asyncio.sleep(1.0)
self._update_initiation_status(None, None)
def mute_transmit(self):
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 6b72b8c4..8da6af47 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2240,6 +2240,7 @@ export default {
audioWs: null,
audioCtx: null,
audioStream: null,
+ audioSourceNode: null,
audioProcessor: null,
audioWorkletNode: null,
audioSilentGain: null,
@@ -2408,6 +2409,49 @@ export default {
formatDuration(seconds) {
return Utils.formatMinutesSeconds(seconds);
},
+ getMediaDevicesApi() {
+ const mediaDevices = navigator?.mediaDevices;
+ if (
+ !mediaDevices ||
+ typeof mediaDevices.getUserMedia !== "function" ||
+ typeof mediaDevices.enumerateDevices !== "function"
+ ) {
+ return null;
+ }
+ return mediaDevices;
+ },
+ getAudioContextConstructor() {
+ return window.AudioContext || window.webkitAudioContext || null;
+ },
+ logWebAudioFailure(stage, error) {
+ const appImage = Boolean(
+ window.electron &&
+ typeof navigator?.userAgent === "string" &&
+ navigator.userAgent.includes("AppImage"),
+ );
+ console.error(
+ `[CallPage:web-audio] ${stage}`,
+ {
+ isElectron: Boolean(window.electron),
+ isAppImage: appImage,
+ userAgent: navigator?.userAgent || "unknown",
+ },
+ error,
+ );
+ },
+ async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") {
+ this.logWebAudioFailure(stage, error);
+ ToastUtils.error(this.$t(errorKey));
+ if (this.config) {
+ this.config.telephone_web_audio_enabled = false;
+ }
+ try {
+ await this.updateConfig({ telephone_web_audio_enabled: false });
+ } catch (updateError) {
+ this.logWebAudioFailure("disable-config-update", updateError);
+ }
+ this.stopWebAudio();
+ },
async ensureWebAudio(webAudioStatus) {
if (!this.config?.telephone_web_audio_enabled) {
this.stopWebAudio();
@@ -2449,12 +2493,23 @@ export default {
return;
}
try {
+ const mediaDevices = this.getMediaDevicesApi();
+ if (!mediaDevices) {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ new Error("navigator.mediaDevices is unavailable"),
+ "start-preflight-media-devices",
+ );
+ return;
+ }
await this.refreshAudioDevices();
const hasInputDevices = (this.audioInputDevices || []).length > 0;
if (!hasInputDevices) {
- ToastUtils.error(this.$t("call.no_audio_input_found"));
- this.config.telephone_web_audio_enabled = false;
- await this.updateConfig({ telephone_web_audio_enabled: false });
+ await this.disableWebAudioBridgeWithError(
+ "call.no_audio_input_found",
+ new Error("No audio input devices detected"),
+ "start-no-input-devices",
+ );
return;
}
@@ -2463,14 +2518,19 @@ export default {
const constraints = hasSelectedDevice
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
: { audio: true };
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
+ const stream = await mediaDevices.getUserMedia(constraints);
this.audioStream = stream;
if (!this.audioCtx) {
- this.audioCtx = new AudioContext({ sampleRate: 48000 });
+ const AudioContextCtor = this.getAudioContextConstructor();
+ if (!AudioContextCtor) {
+ throw new Error("AudioContext is unavailable");
+ }
+ this.audioCtx = new AudioContextCtor({ sampleRate: 48000 });
}
const source = this.audioCtx.createMediaStreamSource(stream);
+ this.audioSourceNode = source;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${wsProtocol}//${window.location.host}/ws/telephone/audio`;
@@ -2510,22 +2570,22 @@ export default {
this.audioWs = ws;
this.refreshAudioDevices();
} catch (err) {
- console.error("Web audio failed", err);
const errorKey =
err?.name === "NotFoundError" || err?.name === "OverconstrainedError"
? "call.no_audio_input_found"
: err?.name === "NotAllowedError"
? "call.microphone_permission_denied"
: "call.web_audio_not_available";
- ToastUtils.error(this.$t(errorKey));
- this.config.telephone_web_audio_enabled = false;
- await this.updateConfig({ telephone_web_audio_enabled: false });
- this.stopWebAudio();
+ await this.disableWebAudioBridgeWithError(errorKey, err, "start-catch");
}
},
async requestAudioPermission() {
try {
- const devices = await navigator.mediaDevices.enumerateDevices();
+ const mediaDevices = this.getMediaDevicesApi();
+ if (!mediaDevices) {
+ throw new Error("navigator.mediaDevices is unavailable");
+ }
+ const devices = await mediaDevices.enumerateDevices();
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
if (devices.length > 0 && !hasAudioInput) {
ToastUtils.error(this.$t("call.no_audio_input_found"));
@@ -2535,12 +2595,12 @@ export default {
const constraints = this.selectedAudioInputId
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
: { audio: true };
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
+ const stream = await mediaDevices.getUserMedia(constraints);
stream.getTracks().forEach((t) => t.stop());
await this.refreshAudioDevices();
return true;
} catch (e) {
- console.error("Permission or device request failed", e);
+ this.logWebAudioFailure("request-permission", e);
const errorKey =
e?.name === "NotFoundError" || e?.name === "OverconstrainedError"
? "call.no_audio_input_found"
@@ -2553,7 +2613,13 @@ export default {
},
async refreshAudioDevices() {
try {
- const devices = await navigator.mediaDevices.enumerateDevices();
+ const mediaDevices = this.getMediaDevicesApi();
+ if (!mediaDevices) {
+ this.audioInputDevices = [];
+ this.audioOutputDevices = [];
+ return;
+ }
+ const devices = await mediaDevices.enumerateDevices();
this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");
if (!this.selectedAudioInputId && this.audioInputDevices.length) {
@@ -2563,11 +2629,13 @@ export default {
this.selectedAudioOutputId = this.audioOutputDevices[0].deviceId;
}
} catch (e) {
- console.error("Failed to enumerate audio devices", e);
+ this.logWebAudioFailure("refresh-devices", e);
+ this.audioInputDevices = [];
+ this.audioOutputDevices = [];
}
},
playRemotePcm(arrayBuffer) {
- if (!this.audioCtx) {
+ if (!this.audioCtx || !arrayBuffer) {
return;
}
const pcm = new Int16Array(arrayBuffer);
@@ -2598,6 +2666,27 @@ export default {
}
},
stopWebAudio() {
+ const ws = this.audioWs;
+ this.audioWs = null;
+ if (ws) {
+ try {
+ ws.onopen = null;
+ ws.onmessage = null;
+ ws.onerror = null;
+ ws.onclose = null;
+ ws.close();
+ } catch {
+ // ignore
+ }
+ }
+ if (this.audioSourceNode) {
+ try {
+ this.audioSourceNode.disconnect();
+ } catch {
+ // ignore
+ }
+ this.audioSourceNode = null;
+ }
if (this.audioProcessor) {
try {
this.audioProcessor.disconnect();
@@ -2610,14 +2699,6 @@ export default {
this.audioStream.getTracks().forEach((t) => t.stop());
this.audioStream = null;
}
- if (this.audioWs) {
- try {
- this.audioWs.close();
- } catch {
- // ignore
- }
- this.audioWs = null;
- }
if (this.remoteAudioEl) {
this.remoteAudioEl.srcObject = null;
this.remoteAudioEl = null;
@@ -2638,6 +2719,12 @@ export default {
}
this.audioSilentGain = null;
}
+ if (this.audioCtx && this.audioCtx.state !== "closed") {
+ this.audioCtx.close().catch(() => {
+ // ignore
+ });
+ }
+ this.audioCtx = null;
},
async getConfig() {
try {
diff --git a/meshchatx/src/frontend/js/MicrophoneRecorder.js b/meshchatx/src/frontend/js/MicrophoneRecorder.js
index 3c2d94f3..aa0f4703 100644
--- a/meshchatx/src/frontend/js/MicrophoneRecorder.js
+++ b/meshchatx/src/frontend/js/MicrophoneRecorder.js
@@ -8,8 +8,30 @@ class MicrophoneRecorder {
this.mediaRecorder = null;
}
+ cleanupMediaStream() {
+ if (!this.microphoneMediaStream) {
+ return;
+ }
+ this.microphoneMediaStream.getTracks().forEach((track) => {
+ try {
+ track.stop();
+ } catch {
+ // ignore track stop failures
+ }
+ });
+ this.microphoneMediaStream = null;
+ }
+
async start() {
try {
+ this.audioChunks = [];
+ if (!navigator?.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
+ return false;
+ }
+ if (typeof MediaRecorder !== "function") {
+ return false;
+ }
+
// request access to the microphone
this.microphoneMediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
@@ -20,7 +42,9 @@ class MicrophoneRecorder {
// handle received audio from media recorder
this.mediaRecorder.ondataavailable = (event) => {
- this.audioChunks.push(event.data);
+ if (event?.data) {
+ this.audioChunks.push(event.data);
+ }
};
// start recording
@@ -29,32 +53,42 @@ class MicrophoneRecorder {
// successfully started recording
return true;
} catch {
+ this.cleanupMediaStream();
+ this.mediaRecorder = null;
return false;
}
}
async stop() {
return new Promise((resolve, reject) => {
- try {
- // handle media recording stopped
- this.mediaRecorder.onstop = () => {
- // stop using microphone
- if (this.microphoneMediaStream) {
- this.microphoneMediaStream.getTracks().forEach((track) => track.stop());
- }
+ if (!this.mediaRecorder) {
+ reject(new Error("Cannot stop recording before start()"));
+ return;
+ }
- // create blob from audio chunks
- const blob = new Blob(this.audioChunks, {
- type: this.mediaRecorder.mimeType, // likely to be "audio/webm;codecs=opus" in chromium
- });
+ const recorder = this.mediaRecorder;
- // resolve promise
- resolve(blob);
- };
+ // handle media recording stopped
+ recorder.onstop = () => {
+ const blob = new Blob(this.audioChunks, {
+ type: recorder.mimeType || "audio/webm;codecs=opus",
+ });
+ this.mediaRecorder = null;
+ this.cleanupMediaStream();
+ resolve(blob);
+ };
+ recorder.onerror = (event) => {
+ this.mediaRecorder = null;
+ this.cleanupMediaStream();
+ reject(event?.error || new Error("MediaRecorder error while stopping"));
+ };
+ try {
// stop recording
- this.mediaRecorder.stop();
+ recorder.stop();
} catch (e) {
+ this.mediaRecorder = null;
+ this.cleanupMediaStream();
reject(e);
}
});
diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index 46810f08..91b08b4d 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -262,6 +262,108 @@ describe("CallPage.vue", () => {
expect(stop).toHaveBeenCalled();
});
+ it("startWebAudio disables bridge when media devices API is missing", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.config = { telephone_web_audio_enabled: true };
+ const updateConfig = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined);
+ const stopWebAudio = vi.spyOn(wrapper.vm, "stopWebAudio");
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ await wrapper.vm.startWebAudio();
+ expect(wrapper.vm.config.telephone_web_audio_enabled).toBe(false);
+ expect(updateConfig).toHaveBeenCalledWith({ telephone_web_audio_enabled: false });
+ expect(stopWebAudio).toHaveBeenCalled();
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ }
+ });
+
+ it("requestAudioPermission returns false when media devices API is missing", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ await expect(wrapper.vm.requestAudioPermission()).resolves.toBe(false);
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ }
+ });
+
+ it("refreshAudioDevices clears stale devices when media devices API is missing", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "old-in" }];
+ wrapper.vm.audioOutputDevices = [{ kind: "audiooutput", deviceId: "old-out" }];
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ await wrapper.vm.refreshAudioDevices();
+ expect(wrapper.vm.audioInputDevices).toEqual([]);
+ expect(wrapper.vm.audioOutputDevices).toEqual([]);
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ }
+ });
+
+ it("ensureWebAudio tears down websocket stream when call is no longer active", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.config = { telephone_web_audio_enabled: true };
+ wrapper.vm.activeCall = null;
+ const wsClose = vi.fn();
+ wrapper.vm.audioWs = {
+ onopen: vi.fn(),
+ onmessage: vi.fn(),
+ onerror: vi.fn(),
+ onclose: vi.fn(),
+ close: wsClose,
+ };
+ const sourceDisconnect = vi.fn();
+ wrapper.vm.audioSourceNode = { disconnect: sourceDisconnect };
+ const processorDisconnect = vi.fn();
+ wrapper.vm.audioProcessor = { disconnect: processorDisconnect };
+ const stopTrack = vi.fn();
+ wrapper.vm.audioStream = { getTracks: () => [{ stop: stopTrack }] };
+ const ctxClose = vi.fn().mockResolvedValue(undefined);
+ wrapper.vm.audioCtx = { state: "running", close: ctxClose };
+
+ await wrapper.vm.ensureWebAudio({ enabled: true });
+
+ expect(wsClose).toHaveBeenCalledTimes(1);
+ expect(sourceDisconnect).toHaveBeenCalledTimes(1);
+ expect(processorDisconnect).toHaveBeenCalledTimes(1);
+ expect(stopTrack).toHaveBeenCalledTimes(1);
+ expect(ctxClose).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.audioWs).toBeNull();
+ });
+
it("getContacts maps telephone contacts list and hydrates visuals", async () => {
const wrapper = mountCallPage();
await flushPromises();
diff --git a/tests/frontend/MicrophoneRecorder.test.js b/tests/frontend/MicrophoneRecorder.test.js
new file mode 100644
index 00000000..2e345b64
--- /dev/null
+++ b/tests/frontend/MicrophoneRecorder.test.js
@@ -0,0 +1,115 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import MicrophoneRecorder from "@/js/MicrophoneRecorder";
+
+describe("MicrophoneRecorder", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns false when mediaDevices API is unavailable", async () => {
+ const recorder = new MicrophoneRecorder();
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ await expect(recorder.start()).resolves.toBe(false);
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ }
+ });
+
+ it("returns false when MediaRecorder is unavailable", async () => {
+ const recorder = new MicrophoneRecorder();
+ const originalMediaRecorder = globalThis.MediaRecorder;
+ const getUserMedia = vi.fn().mockResolvedValue({
+ getTracks: () => [{ stop: vi.fn() }],
+ });
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: {
+ getUserMedia,
+ },
+ });
+
+ Reflect.deleteProperty(globalThis, "MediaRecorder");
+
+ try {
+ await expect(recorder.start()).resolves.toBe(false);
+ expect(getUserMedia).not.toHaveBeenCalled();
+ } finally {
+ if (typeof originalMediaRecorder === "undefined") {
+ Reflect.deleteProperty(globalThis, "MediaRecorder");
+ } else {
+ globalThis.MediaRecorder = originalMediaRecorder;
+ }
+ }
+ });
+
+ it("rejects stop() before start()", async () => {
+ const recorder = new MicrophoneRecorder();
+ await expect(recorder.stop()).rejects.toThrow("Cannot stop recording before start()");
+ });
+
+ it("stops tracks and resolves a blob on successful stop", async () => {
+ const stopTrack = vi.fn();
+ const getUserMedia = vi.fn().mockResolvedValue({
+ getTracks: () => [{ stop: stopTrack }],
+ });
+ const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: {
+ getUserMedia,
+ },
+ });
+
+ const originalMediaRecorder = globalThis.MediaRecorder;
+ class FakeMediaRecorder {
+ constructor() {
+ this.mimeType = "audio/webm";
+ this.ondataavailable = null;
+ this.onstop = null;
+ }
+
+ start() {
+ if (this.ondataavailable) {
+ this.ondataavailable({ data: new Blob(["audio"], { type: this.mimeType }) });
+ }
+ }
+
+ stop() {
+ if (this.onstop) {
+ this.onstop();
+ }
+ }
+ }
+ globalThis.MediaRecorder = FakeMediaRecorder;
+ const recorder = new MicrophoneRecorder();
+
+ try {
+ await expect(recorder.start()).resolves.toBe(true);
+ const blob = await recorder.stop();
+ expect(blob).toBeInstanceOf(Blob);
+ expect(blob.type).toBe("audio/webm");
+ expect(stopTrack).toHaveBeenCalledTimes(1);
+ } finally {
+ if (mediaDevicesDescriptor) {
+ Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "mediaDevices");
+ }
+ if (typeof originalMediaRecorder === "undefined") {
+ Reflect.deleteProperty(globalThis, "MediaRecorder");
+ } else {
+ globalThis.MediaRecorder = originalMediaRecorder;
+ }
+ }
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────